iT邦幫忙

2022 iThome 鐵人賽

DAY 26
0
Modern Web

angular專案開發指南系列 第 26

Angular 單元測試 - Karma

  • 分享至 

  • xImage
  •  

前言

透過自動化測試取代人工測試,降低測試成本,自動化測試帶來速度快、可重複與自動化的測試工程。

程式寫完之後,通常會需要做測試,可能就是跑跑看看東西或 log 內容有沒有符合預期,手動測試很麻煩,也容易忘東忘西,流程繁雜時手動測試也不是個好選擇,Angular 內建單元測試 (Unit Test) 功能,可以幫你解決這些問題,Angular 期望框架使用者能,

  1. 功能盡量細分,元件化,模組化,讓每個元件可測試。
  2. 做好初期的自動測試工作,降低後期維護成本。

測試的種類

單元測試 (Unit testing)

以程式碼的最小單位進行測試,保護程式邏輯不會在系統維護的過程中遭到破壞,也進一步確保維護中的程式碼品質。

整合測試 (Integration testing)

整合多方資源進行測試,確保模組與模組之間的互動行為正確,也讓不同模組在各自開發維護的過程中不會因為功能調整而遭到破壞。 一般來說,整合測試的數量應該介於單元測試與端對端測試之間,針對幾個主要的模組進行整合測試即可。

端對端測試 (E2E testing)

所謂的「端對端」(E2E) 是指從使用者的角度出發,對真實系統進行測試

透過人工對已經完整部屬的網站進行測試,因此可以驗證出系統是否符合客戶的實際需求。這部分也可以透過撰寫 E2E 測試程式來進行自動化,增加測試效率。

測試環境就是一套完整的系統部署,如果能透過 Docker 容器技術進行部署,那麼設立測試環境的成本將會大幅降低。由於端對端測試可以正確反映出使用者需求是否滿足,因此商業價值較高,但要對一個複雜的系統進行完整的 E2E 測試開發,可能會有相當多的測試案例,如果真的要做到 100% 的測試覆蓋率,成本也會相對的提高許多!


撰寫自動測試程式的價值

傳統人工的端對端測試,經常會發生下列問題

  1. 工程師在改 Code 的過程中,A 功能改好了,但 B 功能卻壞掉了。
  2. 網站請工程師修正一個小問題的時候,程式沒出錯但畫面跑版了。
  3. 原本勾選一個(Checkbox)後應該出現某個欄位,某次更新後欄位卻不再顯示,但人工測試時沒發現,是客戶主動通知才知道有這個問題 。
  4. 客戶上個月提出的修正版本已經改好,但最近的版本卻又不小心改壞了 。

自動化的測試取代傳統人工測試

原本跑一輪測試要花上 20 分鐘,寫完測試程式後,可能只需要 20 秒就可以完成,對一個穩定的產品或需要長時間維護的專案來說,自動化的端對端測試除了一開始需要投入人力與時間開發代碼之外,最終所節省的成本理論上會遠大於人工測試的成本。

因此,導入自動化的端對端測試有其必要性,尤其像是針對價值型高的網站 (如電子商務、品牌形象網站)、操作動線複雜的企業表單都可以考慮開始慢慢加入自動化的端對端測試,用程式來跑測試。

如何導入自動化的端對端測試

  1. 優先針對商業價值最高的功能進行端對端測試開發,用以保護該網站最重要的需求不會在網站維護的過程中降低服務品質, 我們知道實現 E2E 自動化測試的成本並不低,也知道實現 100% 的 E2E 測試是不太務實的做法,因此選擇相對重要的功能進行 E2E 測試開發,是相當重要的一個步驟,你必須設定好目標,逐步來實現 E2E 開發。
  2. 另外一個挑選的法則就是,當你的網站在人工測試的過程中,發現一個錯誤就加入一個相對應的 E2E 測試,這樣至少可以保證日後不會再出現相同問題,減少之後回歸測試的時間,如此一來也可以大幅提升程式品質。
  3. 整合 CI/CD 持續建置/持續交付 自動化測試如果可以整合到 CI/CD 流程中,那是最好的了。每一次的新版本如果都能自動化的進行 E2E 測試,更能確保網站在上線之前不會發生重大錯誤。
  4. 如果測試的覆蓋率夠高,並且可以放心 E2E 測試的品質,甚至可以做到全自動上版! 透過 CI/CD 進行自動化的執行 E2E 測試還有另一個好處,那就是開發人員不用自己執行 E2E 測試,所以不會因為執行測試而中斷開發作業,所有測試都在 CI 伺服器執行,只要發現到測試失敗就會自動寄信通知開發者,當有問題發生的時候,也可以在第一時間得知與修正。
  5. 要開始學習自動化的端對端測試,可以參考以下幾個步驟: 選擇一個好用的測試框架 基本上可以模擬人類對網站進行測試有很多方法,你用任何一種程式語言都可以實現自動化的端對端測試。不過一般來說我們還是會選擇一套不錯的框架,以降低初期的學習成本。 市面上現有的 E2E 框架非常多,知名的有 Protractor、CasperJS、Nightwatch.js、Testcafe、Cypress 等等。

不需花時間開發自動測試的專案

  1. 需求不確定 (是否有穩定的需求需要被保護)
    專案建置初期,需求尚未明朗又必須交付成果的時候,撰寫測試就顯得不具意義,因為當需求改變時,測試程式肯定也要跟著改寫,開發成本也會跟著墊高。 業界有很多人推廣 TDD (測試先行開發)。但是這樣的點子不見得適用於所有專案類型,像是 PoC (驗證可行性) 的專案,對於設計架構上就沒有必要。

  2. 價值性不高 (是否有重要的功能需要被保護)
    取決於網站經營者對價值的設定,其實跟工程師沒多大關係。我們都知道撰寫測試需時間,不願意給予合理的工時撰寫測試,自然也就沒有撰寫測試的可能性。如果老闆請人把網站功能全部使用一遍 (端對端測試),就能知道網站是否可用,那他會毫不遲疑的請個人去測一遍網站所有功能。因為這是他唯一能理解的價值呈現方式!


關於自動化測試的建議

單元測試整合測試

建議專案時程延長一倍,或有多餘人力時再進行這部份測試的自動化,原因如下,

  1. 學習測試開發有門檻,需要花一些時間熟悉與試錯。
  2. 維護測試的過程,通常會隨著需求變更而導致成本大幅增加。
  3. 單元測試整合測試都無法確保程式最終的執行結果是客戶真正要的,因為這兩種測試類型,一個是針對程式碼的最小單位進行測試,另一個是針對模組與模組之間的整合進行測試,就算測試結果是成功的,最終還是無法確保需求被滿足

端對端測試
以真實完整的系統進行測試,從開啟瀏覽器開始,一步步的操作與輸入,並且讓瀏覽器與後端 API 進行互動,然後直接透過最終的顯示狀態來診斷其結果是否符合預期。如果測試結果正確,通常也意味著「需求正確」,因此這可以說是最容易讓客戶、老闆放心的測試類型。也是為什麼一般公司寧願不去寫單元測試、整合測試,卻願意投入端對端測試的原因。

元件需求如果不明確或功能切分不明,會提高單元測試代碼的維護成本。


如何進行 Angular的單元測試

Angular 附帶 Jasmine,這是一個 JavaScript 框架,使您能夠編寫和執行單元和集成測試。茉莉花由三個重要部分組成:

具有用於構建測試的類和函數的庫。 測試執行引擎。 以不同格式輸出測試結果的報告引擎。

使用Angular cli指令 ng test 將執行專案中所有的 spec檔案

執行畫面如下(全正確時)

p62

執行畫面如下(有錯誤時)

p63


單元測試撰寫規則

同一個模組的測試寫在一個 test suite裡,避免測試項目分散。

p64

單元測試撰寫規則

p65

  1. 以建立元件時自動產生的 spec檔為基準
  2. 直接填寫被測元件的代碼註解
  3. Arrange (環境設定)
  4. 直接填寫被測單元的代碼註解
  5. 直接填寫被測單元的代碼名稱
  6. 直接填寫被測單元的代碼期望功能
  7. Destroy

單元測試範例

單元 - Component

it('### 選擇聊天室 clickChatRoom [設定被選擇聊天室的未讀狀態]', () => {
    component.chatRoomList = fakeChatRoomList;

    // [Spy] navigateTo如果被調用回傳 undefined
    utilitiesServiceSpy.navigateTo.and.returnValue();

    component.clickChatRoom(0);

    // navigateTo被執行一次
    expect(utilitiesServiceSpy.navigateTo.calls.count()).toBe(1);
    expect(component.chatRoomList[0].FIsUnRead).toBeFalse();
});

關於 utilities.service的單元測試

p66

測試撰寫規則

基本的單元測試(日期時間格式產生器)

describe('### 日期時間格式產生器', () => {
    it('### 時間為空時的回傳值', () => {
        const item = service.dateTimeFormat(null);
        expect(item).toBeUndefined();
    });

    it('### 時間為 timestamp時的回傳值', () => {
        const timeStr = service.dateTimeFormat(+new Date());

        // 回傳值為字串
        expect(typeof timeStr === 'string').toBe(true);

        // 回傳值 Date物件存在 getMonth方法
        expect(typeof new Date(timeStr).getMonth === 'function').toBe(true);
    });
});

p67

測試撰寫規則

使用 for loop逐一進行單元測試

describe('### 判斷是否為 JSON格式', () => {
    const input = [null, '{}', 'Hello', '{"a":"first","b":"second"}'];
    const output = [false, true, false, true];

    for (const i in input) {
        it(`### 輸入 ${input[i]}`, () => {
            expect(service.isJson(input[i])).toBe(output[i]);
        });
    }
});

p68

Spinners載入中元件 [是否被建立]

describe('## Spinners載入中元件 SpinnerComponent', () => {
    let component: SpinnerComponent;
    let fixture: ComponentFixture<SpinnerComponent>;

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [SpinnerComponent],
        }).compileComponents();

        fixture = TestBed.createComponent(SpinnerComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    }));

    it('### Spinners載入中元件 [是否被建立]', () => {
        expect(component).toBeTruthy();
    });
});

p69

無害化處理 SafePipe

describe('## 無害化處理 SafePipe', () => {
    beforeEach(() => {
        TestBed.configureTestingModule({
            imports: [BrowserModule],
        });
    });

    it('### 無害化處理 [是否被建立]', () => {
        const domSanitizer = TestBed.get(DomSanitizer);
        const pipe = new SafePipe(domSanitizer);
        expect(pipe).toBeTruthy();
    });

    it('### 無害化HTML [是否為安全HTML]', () => {
        const domSanitizer = TestBed.get(DomSanitizer);
        const pipe = new SafePipe(domSanitizer);
        const SafeHtml = pipe.transform('<p>Unit Test</p>', 'html');
        expect(typeof SafeHtml).toBe('object');
    });
});

p70

防止連續點擊元件 DebounceClickDirective

describe('## 防止連續點擊元件 DebounceClickDirective', () => {
    let component: DebounceClickDirectiveTestingComponent;
    let fixture: ComponentFixture<DebounceClickDirectiveTestingComponent>;
    let inputElem: DebugElement;
    let subscription: Subscription;

    beforeEach(() => {
        TestBed.configureTestingModule({
            imports: [FormsModule],
            declarations: [DebounceClickDirective, DebounceClickDirectiveTestingComponent],
        }).compileComponents();

        subscription = new Subscription();
        spyOn(subscription, 'unsubscribe').and.callThrough();
        fixture = TestBed.createComponent(DebounceClickDirectiveTestingComponent);
        fixture.detectChanges();
        component = fixture.componentInstance;
        inputElem = fixture.debugElement.query(By.css('input'));
    });

    it('### 防止連續點擊元件 [是否被建立]', () => {
        const directive = new DebounceClickDirective();
        expect(directive).toBeTruthy();
    });

    it('### 防止連續點擊元件 [500ms後啟動點擊事件]', fakeAsync(() => {
        const directiveElem = fixture.debugElement.query(By.directive(DebounceClickDirective));
        spyOn(component, 'test').and.stub();
        fixture.detectChanges();

        expect(directiveElem).toBeDefined();
        expect(component.directive.debounceTime).toBe(500);
        expect(component.value).toBe('');

        inputElem.nativeElement.value = 'test';
        inputElem.nativeElement.dispatchEvent(new Event('input'));
        tick(500);
        fixture.detectChanges();

        expect(component.test).toHaveBeenCalled();
        expect(component.value).toBe('test');

        component = null;
    }));
});

p71

私有方法測試範例 使用 Spy製造 mock response並監聽調用

describe('### 方法自動測試', () => {
    const fakeChatRoomList = [
        {
            FId: '17d6fe9f-8320-0932-4a23-00155dd13fcf',
            FIcon: '測試',
            FIsUnRead: true,
            FUserName: '測試',
            FUpdateTime: '2021-12-09 14:54:07.000',
            FLastMessage: 'system2',
        },
    ];

    it('### 選擇聊天室 clickChatRoom [設定被選擇聊天室的未讀狀態]', () => {
        component.chatRoomList = fakeChatRoomList;

        // [Spy] navigateTo如果被調用回傳 undefined
        utilitiesServiceSpy.navigateTo.and.returnValue();

        component.clickChatRoom(0);

        // navigateTo被執行一次
        expect(utilitiesServiceSpy.navigateTo.calls.count()).toBe(1);
        expect(component.chatRoomList[0].FIsUnRead).toBeFalse();
    });

    // 私有方法測試範例
    it('### 清除聊天列表 clearChatList [是否清除 chatRoomList/chatRoomListPage]', () => {
        component.chatRoomList = fakeChatRoomList;
        component.chatRoomListPage.count = 1;
        component = componentExtend.clearChatListExtend(component);

        expect(component.chatRoomList.length === 0).toBeTrue();
        expect(component.chatRoomListPage.count === 0).toBeTrue();
    });
});

p72


使用 HTTP與後端服務進行通訊

describe('## 使用 HTTP與後端服務進行通訊', () => {
    let service: HttpService;
    let httpMock: HttpTestingController;
    let utilitiesServiceSpy: jasmine.SpyObj<UtilitiesService>;

    beforeEach(() => {
        const spy = jasmine.createSpyObj('UtilitiesService', ['configParser', 'getMockSession']);
        TestBed.configureTestingModule({
            imports: [HttpClientTestingModule],
            providers: [{ provide: UtilitiesService, useValue: spy }],
        });
        service = TestBed.inject(HttpService);
        httpMock = TestBed.inject(HttpTestingController);
        utilitiesServiceSpy = TestBed.inject(UtilitiesService) as jasmine.SpyObj<UtilitiesService>;
    });

    it('### 使用 HTTP與後端服務進行通訊 [是否被建立]', () => {
        expect(service).toBeTruthy();
    });

    it('### httpGET [期待並回復請求]', () => {
        service.httpGET('http://localhost:3000/ecp-agent-list');
        const req = httpMock.expectOne({ method: 'GET' });
        const resp = [{ result: 'Unit Test' }];
        req.flush(resp);

        expect(req.request.method).toEqual('GET');
        expect(req.request.responseType).toEqual('json');
    });

    afterEach(() => {
        // 驗證沒有發起過預期之外的請求
        httpMock.verify();
    });
});

p73


聯絡人註冊元件

describe('## 聯絡人註冊元件', () => {
    describe('## 聯絡人註冊元件 [同意書頁面]', () => {
        let component: RegisterUserComponent;
        let fixture: ComponentFixture<RegisterUserComponent>;

        beforeEach(async(() => {
            TestBed.configureTestingModule({
                imports: [HttpClientTestingModule, RouterTestingModule, RouterTestingModule.withRoutes([])],
                declarations: [RegisterUserComponent, SafePipe],
                providers: [],
            }).compileComponents();

            fixture = TestBed.createComponent(RegisterUserComponent);
            component = fixture.componentInstance;
            component.i18n = JSON.parse(localStorage.getItem('languages'));
            component.TEXTRESOURCE = TEXTRESOURCE;

            component.ngOnInit();
            fixture.detectChanges();
        }));

        it('### 同意書頁面 [勾選同意框 -> 點擊同意按鈕 -> 確認換到下一頁]', () => {
            // 確認目前在同意書頁面
            expect(component.pageMode).toBe('agreement');

            fixture.whenStable().then(() => {
                const debugElement: DebugElement = fixture.debugElement;
                const htmlElement: HTMLElement = debugElement.nativeElement;
                const checkbox = htmlElement.querySelectorAll('.form-check-input');

                // 勾選同意框
                checkbox[0]['checked'] = true;
                component.isAgreementCheck = true;
                fixture.detectChanges();

                // 點擊同意按鈕
                component.goRegister();

                // 確認換到下一頁
                expect(component.pageMode).toBe('form');
            });
        });
    });
});

結論

Angular 框架下開發的元件適合做單元測試,使用 Angular-CLI 建立元件時甚至會幫你自動建一個 .spec 的單元測試檔,可見 Angular 官方是希望框架使用者做好自動測試的,甚至是鼓勵 TDD 的開發流程,這時確保元件的獨立性與結構性就是一個重要的工作,否則單元測試會很難進行。

結束了元件的單元測試,接下來使用 Cypress 框架搭配 Angular 來進行 E2E 測試。


參考

Testing Angular A Guide to Robust Angular Applications

Jasmine 文檔:內置匹配器

Jasmine 教程:自定義匹配器


上一篇
路由守衛與登入模組實作
下一篇
E2E 自動測試 - Cypress
系列文
angular專案開發指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言